Skip to content

fix(plugin-detail): copy object-valued cells as JSON, not [object Object] - #8460

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-8395-copyfield-object-values
Sep 8, 2026
Merged

fix(plugin-detail): copy object-valued cells as JSON, not [object Object]#8460
os-justin merged 1 commit into
mainfrom
claude/issue-8395-copyfield-object-values

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #8395

DetailSection's click-to-copy built its payload with String(value), and String() of an object is the literal text [object Object]. The affordance is offered for every non-empty value — objects included (canCopy is hasCellValue, objectui#8376) — so a reader clicking the copy button, the row, or pressing Enter on an address, geolocation, JSON, file, expanded-lookup, repeater or image cell silently got a placeholder on the clipboard while the cell beside the button rendered that same value correctly. Nothing errors; it is noticed only on paste.

The change is one read site: objects are serialized with JSON.stringify, everything else keeps String().

Per-kind payloads — measured, not reasoned

Rendered via a real DetailSection at innerWidth 1280; the observed value is the ARGUMENT handed to navigator.clipboard.writeText.

field kind the cell renders clipboard BEFORE clipboard AFTER
address 1 Main St, Springfield, IL 62704, USA [object Object] {"street":"1 Main St","city":"Springfield","state":"IL","postalCode":"62704","country":"USA"}
geolocation 30.2742, 120.1551 [object Object] {"latitude":30.2741567,"longitude":120.1551234}
json {"a":1,"b":["x","y"]} [object Object] {"a":1,"b":["x","y"]}
file (single) contract.pdf [object Object] {"name":"contract.pdf","url":"https://cdn.example.com/contract.pdf"}
lookup (expanded) Acme Corp [object Object] {"id":"acct-1","name":"Acme Corp"}
file (array) 2 files [object Object],[object Object] [{"name":"a.pdf"},{"name":"b.pdf"}]
repeater a count [object Object],[object Object] [{"label":"one"},{"label":"two"}]
image an img element, no text [object Object] {"name":"logo.png","url":"https://cdn.example.com/logo.png"}
multiselect AlphaBeta alpha,beta ["alpha","beta"]

⚠️ One payload moves without having been broken — the multiselect. An array IS an object, so ['alpha','beta'] now copies as a JSON array. The new form is lossless where the old one was ambiguous for any value containing a comma. It is called out here rather than buried: it is the only row whose BEFORE column was not already a placeholder.

⭐ The non-regression half

Every correct-today scalar payload is byte-identical, and the pin asserts the rendered text beside it, so it is red for any later "just copy what the cell renders" rewrite:

field kind the reader SEES the clipboard GETS (unchanged)
number (scale 2) 16.00 16
currency 1,234.50 1234.5
percent 12% 0.123
date Mar 4 2026-03-04
select Closed Won won
text Plain String Value Plain String Value
boolean (a checkbox, no text) true

What this deliberately does NOT decide

  • ⚠️ The JSON blob is a defensible default, not a settled contract. The read site now says so in place, naming objectui#8395's option B — a shared value-to-text formatter reusing the cell renderers' own formatters (formatAddress, objectui#4037) — as where the real per-kind contract gets decided. That needs product answers that do not exist yet (address under which locale, geolocation at stored precision or displayed rounding, json compact or pretty, file filename or list or URL, multiselect labels or values, lookup name or id, image URL or filename) and is a separate card.
  • Copying the rendered text (option A) was measured and rejected. It is the worse contract for 9 of 17 field types and loses data silently: date renders Mar 4 (the year is gone), percent renders 12% against a stored 0.123 (a different quantity), datetime concatenates to an unparseable 3/4/20265:06 am, image and boolean render no text at all — so those two would copy the EMPTY STRING, an implementation strictly worse than the defect. It would also make the payload display-locale dependent.
  • Withdrawing the affordance from text-less cells (option D) stays out: it would narrow canCopy away from hasCellValue, whose three readers must agree per objectui#8376's docblock.
  • objectui#8440 is not addressed here and its code path is byte-for-byte untouched: the password / secret branch of this same handler (copy writes the raw secret while the cell renders the mask) is in the maintainer's decision box. The pin asserts nothing about it on purpose, so whichever way that card is answered, no case in this file turns red. That card remains open.

The one addition beyond the literal one-liner

JSON.stringify throws on a cycle, and this runs inside a click handler. The stringify is therefore wrapped in the SAME try / catch that JsonCellRenderer already applies to this exact operation on this exact value, falling back to today's string form. It is pinned (a self-referencing value still copies [object Object] and the click does not throw) and is the only input for which that text is still written. Strike it if you would rather have the bare call.

Ablation — proof the pin can fail

Run from the committed implementation, restoring the copy handler's bare String(value). ⚠️ There is a SECOND String(value) in this file — displayValue's no-renderer fallback — which is NOT this defect; the mutation is anchored on the handler body and that second site is asserted unmoved.

HEAD blob            014ec9dc9f356d7c2f0267c5c4f1389cfca70324
on disk, mutated     6f5bde22876b224f753718e4a028eb41a9d932c3   ⇒ the mutation reached disk
anchors  JSON.stringify(value)                1 -> 0
         bare one-liner (copy handler)        0 -> 1
         '      return String(value);' (L420) 1 -> 1   (the OTHER site, unmoved)
result   Tests 9 failed | 8 passed (17)
restore  on-disk hash == HEAD blob AND `git diff HEAD` empty   ⇒ restored BY STATE, not by an exit code

The nine red rows, by name — every one an object-valued kind, each failing on its payload (Received: "[object Object]") while its rendered-value CONTROL still passed, so the failure is about what was copied and never about a blank cell:

OBJECT — 'Billing Address' / 'Office Location' / 'Payload' / 'Contract' /
         'Owner' / 'Attachments' / 'Line Items' copies parseable JSON …
OBJECT — an image cell copies its JSON even though it renders no text
OBJECT — a multiselect copies its stored values as a JSON array

The eight that stayed green under the mutation are exactly the ones that must: the six scalars, the boolean, and the unserializable-value case.

Verification

what verdict
the pin pnpm exec vitest run packages/plugin-detail/src/__tests__/DetailSection.copyObjectValues-8395.test.tsxTests 17 passed (17), lock VERDICT command-exit 0
whole affected package pnpm exec vitest run packages/plugin-detail/Test Files 138 passed (138), Tests 1248 passed (1248), lock VERDICT command-exit 0
types (incl. tests) closure build then pnpm --filter @object-ui/plugin-detail type-check (tsc --noEmit && tsc -p tsconfig.test.json) — lock VERDICT command-exit 0
lint package-scoped eslint . in packages/plugin-detail — exit 0, 901 problems (0 errors, 901 warnings), identical to the pre-change baseline and none in the changed files
governed surface check-governed-queue-guard --test on all three changed paths — NOT GOVERNED — 3 path(s) checked against 5 governed surface(s); none matched.
control bytes grep -naP over the changed files: zero hits, with a lit control that fires

No downstream package asserts this payload: of the nine test files in the repo that touch clipboard, the only two that render this surface are in packages/plugin-detail.

Changeset

minor, on @object-ui/plugin-detail — this moves a shipped clipboard payload, which is user-visible behaviour; the landed precedent for a record:details behaviour change is PR #8396. The repo forbids major. Gate verdicts, quoted:

✅  2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/8395-detail-copy-object-values.md.
✅  No changeset declares a `major` bump.

Generated by Claude Code

…ject]`

`DetailSection`'s click-to-copy built its payload with `String(value)`, and
`String()` of an object is the literal text `[object Object]`. The affordance is
offered for every non-empty value — objects included — so a reader clicking the
copy button, the row, or pressing Enter on an address, geolocation, JSON, file,
expanded-lookup, repeater or image cell silently got a placeholder on the
clipboard, while the cell beside the button rendered that same value correctly.

Objects are now serialized with `JSON.stringify`. Non-objects keep `String()`
byte-for-byte: a number still copies `16` (not the rendered `16.00`), a percent
`0.123` (not `12%`), a date `2026-03-04` (not `Mar 4`).

Copying the RENDERED text was measured and rejected — it is the worse contract
for 9 of 17 field types and loses data silently (an `image` or `boolean` cell
renders no text at all, so it would copy the empty string). The JSON blob is a
defensible default, not a settled contract; the read site says so in place.

Card: objectui#8395

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3472.9 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-B-bNdC01.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 498.87KB 114.10KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 189.15KB 52.56KB
fields (index.js) 243.15KB 61.40KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.52KB 46.34KB
plugin-dashboard (index.js) 131.48KB 34.45KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 248.79KB 63.97KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.58KB 56.63KB
plugin-kanban (index.js) 55.40KB 15.71KB
plugin-list (index.js) 112.74KB 27.70KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.54KB 20.84KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

PM contract review — accepted, flipped to ready, auto-merge armed. All three open questions: A, A, A. None of them needs the maintainer.

Q1 — multiselect moves: A, as shipped

typeof value === 'object' covers arrays, so ['alpha','beta']["alpha","beta"] is what ruling C literally says, and my ruling's byte-identical clause was scoped to the five scalar payloads — an array was never in it.

The deciding argument is yours, not mine: the old 'alpha,beta' is ambiguous for any value containing a comma. That is a latent defect the move removes, not a cost it incurs. B is rejected for the reason you gave — it invents a third rule at the read site that the ruling did not authorise, which is the per-kind fiat C exists to defer to option B of objectui#8395, not to smuggle in early.

Q2 — the try/catch: A, keep it

A bare JSON.stringify converts today's harmless '[object Object]' into an uncaught throw out of an onClick, with the clipboard never written. That is strictly worse than the defect this PR fixes, and it would be a new crash path introduced by a bug fix. Mirroring JsonCellRenderer's existing guard on the same operation over the same value is precedent, not invention. Three lines, one self-contained pin. Keeping it.

Q3 — nine payloads, not seven: A, pin all nine

B would ship two behaviour changes with no pin, and — as you say — image is precisely the row an option-A rewrite would turn into the empty string. That row is the regression guard for the option I rejected. Pinning it is the point.

⭐ And this exposes a real limitation in my control rule. I wrote that "every object row must also assert the cell rendered its value". The image cell renders an <img> element and no text, so that rule as phrased cannot express its control. Adapting the control to the element was correct. The rule should have read "every object row must assert the cell rendered something, by whatever the cell's own output is" — carrying that forward.

What the pin does that matters

The scalar half asserting the rendered text beside the payload is the load-bearing part: it makes this file go red for any later "copy the rendered text" rewrite, which is the option my own earlier brief preferred and your previous run's measurement falsified. That is the durable outcome of this card — not the JSON.stringify, but the fact that A can no longer creep back in silently.

Two other choices I want on the record as right:

  • Geolocation's stored precision deliberately exceeds the displayed rounding (30.2741567 vs a rendered 30.2742), so no assertion in that row is satisfiable by re-deriving from the rendered text. That is what makes the row a real test of what was copied rather than of what was shown.
  • The querySelector('button') trap is itself pinned — the boolean row's first button carries role='checkbox' (the Radix Checkbox). A trap recorded in a brief is advice; a trap pinned in a test is a guard.

Scope discipline

objectui#8440's password/secret branch byte-for-byte untouched with the pin deliberately asserting nothing about it, and the read-site docblock naming the JSON blob as a defensible default, not a settled contract with option B cited — both exactly as ruled. Not pinning objectui#8441's 2 项 label (the pin asserts only the count) is the right call too: it keeps that card's fix from reddening this file.

Noted for the next reader, since you recorded it rather than quietly fixing it: your first address fixture spelled postal_code, which formatAddress correctly drops (it reads postalCode per objectstack#5143, plus legacy zipCode). Had that not been caught, the address row's rendered-text control would have been quietly weaker than it looks — a control that passes for the wrong reason.


Generated by Claude Code

@os-justin
os-justin enabled auto-merge September 8, 2026 00:41
@os-justin
os-justin added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit c13d39e Sep 8, 2026
34 checks passed
@os-justin
os-justin deleted the claude/issue-8395-copyfield-object-values branch September 8, 2026 01:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DetailSection's click-to-copy writes [object Object] to the clipboard for every object-valued cell (address, geolocation, JSON, attachment)

2 participants